Skip to content

Issue 32: end the send side on an abrupt local close, not only on EOF - #38

Merged
myobie merged 3 commits into
mainfrom
fix/issue-32-abrupt-close-converges
Aug 3, 2026
Merged

Issue 32: end the send side on an abrupt local close, not only on EOF#38
myobie merged 3 commits into
mainfrom
fix/issue-32-abrupt-close-converges

Conversation

@myobie

@myobie myobie commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Closes #32.

It was never a slow path

A finished outbound session could leave the server counting it as attached for 40s+ on Linux, while macOS released in ~53ms. A 750x gap is a different code path, not a slower one.

A dropped local socket reads as a clean zero-length read on macOS and as an error on Linux. Only the EOF branch recorded the send close — and only a recorded send close makes the writer emit Frame::Close. With no such frame the server waited for bytes that could never arrive.

PR #31 released the recycle guard on the error branch so the stall could not pin the endpoint, and deliberately left teardown semantics alone. This finishes the job: the abrupt branch now calls mark_send_closed, which records the final offset and releases the guard.

Only the send side closes. The session may still be writing queued remote output, and a half-close is not a close.

Proved without Linux and without CI timing

The platform difference is a single decision — EOF vs error — so a test injects it directly through the boxed local reader. Before the change the abrupt case left send_closed at None while the clean case set Some(5); now they agree.

That turns a Linux-only, racy, 40-second failure into a deterministic assertion that runs anywhere in milliseconds. This unit test is the regression guard, confirmed failing on Linux CI against the pre-fix teardown in #37.

A correction I owe the reviewer

I first wrote an end-to-end test and claimed it was the Linux regression guard. It is not, and I was wrong about it twice.

  • v1 used a shell. It passed on Linux with the defect present.
  • I diagnosed that as the shell's output masking the defect via a write failure, rewrote it as a quiet tunnel, and it also passed on Linux with the defect present. My masking explanation was wrong too.

The actual reason: dropping a UnixStream in-process closes it cleanly, so both platforms take the EOF branch and neither reaches the error branch where issue 32 lives. Reaching it needs an abrupt close — a killed process or an RST — which is how the original trace produced it.

That test is renamed to what it really proves: a clean local close detaches the session on the server. Worth keeping, not the issue-32 guard.

I found this only because I ran the pre-fix code against the new tests on Linux instead of assuming they worked. A test that guards nothing while claiming to is worse than no test.

What CI proves and what it does not

Green Linux CI proves the teardown converges in a CI container. It is not Droppy or Hetz, and this repo has twice called a defect fixed on green CI while a real device still failed. Whether a host check is wanted is root's call, not mine.

Tests

194 lib tests green. Changed files pass rustfmt.

myobie added 3 commits August 4, 2026 01:29
Issue 32. A finished outbound session could leave the SERVER counting it as
attached for 40 seconds or more on Linux, while macOS released in about 53 ms.

It was never a slow path. A dropped local socket reads as a clean zero-length
read on macOS and as an error on Linux. Only the EOF branch recorded the send
close, and only a recorded send close makes the writer emit Frame::Close. With
no such frame the server waited for bytes that could never arrive. A 750x
platform gap is a different code path, not a slower one.

PR 31 released the recycle guard on this branch so the stall could not pin the
endpoint, and deliberately left the teardown semantics alone. This finishes the
job: the abrupt branch now calls mark_send_closed, which records the final
offset and releases the guard. Only the send side closes, because the session
may still be writing queued remote output and a half-close is not a close.

Proved without Linux and without CI timing. The platform difference is one
decision, EOF against error, so a test injects it directly through the boxed
local reader. Before this change the abrupt case left send_closed at None while
the clean case set it to Some(5); now they agree. That turns a Linux-only, racy,
40-second failure into a deterministic assertion that runs anywhere in
milliseconds.

A second test asserts the server-side outcome end to end: after the client drops
its local end, the server's active attach count must reach zero. It carries a
positive control that the server really counted the attach first, so a server
that never counted the session cannot pass it for the wrong reason. That test
passes on macOS either way, because macOS always took the branch that worked; it
is the Linux regression guard.

The recycle-guard release is now asserted for BOTH endings beside the new case,
so a later change cannot fix one ending and quietly regress the other.

194 lib tests green.
The first end-to-end test used a shell and passed on Linux CI with the defect
present, so it guarded nothing. A shell streams output back, so a dropped local
socket fails on the next WRITE and tears the session down by a second route that
hides the missing close frame.

The original trace was a quiet tunnel. With nothing to write back, the missing
frame is the only thing that could end the session, which is what isolates the
defect.
It does not, and I presented it as though it did. Twice.

Issue 32 lives in the branch where the local read returns an ERROR. Dropping a
UnixStream in-process closes it cleanly, so both platforms take the EOF branch
in this test and neither reaches the defect. Linux CI confirmed it twice against
the pre-fix teardown: the shell version passed, and so did the quiet-tunnel
version I wrote to replace it.

My masking explanation for the first failure was wrong too. The shell was not
hiding the defect behind a write failure; the test never reached the error
branch at all.

The real guard is the unit test, which injects the ending directly and fails on
every platform when the defect is present. This test is renamed to what it
actually proves: a CLEAN local close detaches the session on the server. That is
worth keeping and is not the issue-32 guard.
@myobie
myobie merged commit b6ffc0a into main Aug 3, 2026
2 checks passed
@myobie
myobie deleted the fix/issue-32-abrupt-close-converges branch August 3, 2026 23:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Linux: outbound tunnel teardown can stall for 40s+ after the local side finishes

1 participant